Skip to content

C++17 新特性 ​

C++17 是一个「中」规模版本(它本来应该是一个 major 版本)。虽然没有模块/协程级别的变革,但结构化绑定、if constexpr、CTAD、string_view/optional/variant 这一族词汇类型的实用性极高,日常代码风格从 C++17 开始明显变化。本文按语言与标准库两侧逐项展开,附用法、原理与陷阱。

编译器支持情况见 Compiler support for C++17。系列总览见 现代 C++ 新特性汇总。

语言特性 ​

保证拷贝省略 ​

纯右值(prvalue)初始化对象时强制省略拷贝/移动——不再依赖编译器优化,语言直接保证:

cpp
struct Immovable {
    Immovable() = default;
    Immovable(const Immovable&) = delete;             // 不可拷贝
    Immovable& operator=(const Immovable&) = delete;
};

Immovable make() { return Immovable{}; }   // OK:无拷贝无移动
Immovable x = make();                       // 依然 OK:直接在 x 的位置构造

原理:C++17 重新定义了 prvalue——它不再是「一个临时对象」,而是「构造某个对象的配方」,直到真正需要对象的位置(物化)才构造。因此 T a = T(T(T())); 一趟构造直达终点。返回不可移动类型从此合法,return 局部变量也少了一层移动开销担忧。

按值捕获 *this ​

lambda 捕获列表支持 [*this],把当前对象拷贝一份进闭包(P0018R3):

cpp
struct Sensor {
    int reading{};
    auto snapshot() const {
        return [*this] { return reading; };   // 拷贝整个对象进闭包
    }
    // [this] 捕获的是指针:lambda 活得比对象久就悬垂
    // [*this] 捕获副本:适合异步回调、跨线程任务
};

C++20 的相关变化是 [=] 隐式捕获 this 被弃用(应写 [=, this]),见 C++20 新特性。

超对齐类型的动态分配 ​

new 会正确处理对齐要求超过默认值(通常 16 字节)的类型:

cpp
struct alignas(64) CacheLine {      // 对齐到缓存行
    char data[64];
};

auto* p = new CacheLine;            // 自动调用 operator new(size, align_val_t{64})
delete p;                           // 匹配的对齐 delete

C++17 之前,new 不感知 alignas,超对齐对象在堆上可能不对齐(SIMD、无锁结构的经典坑)。现在由语言保证。

严格指定运算顺序 ​

若干二元运算的操作数求值顺序从「未指明」改为「有确定顺序」:

cpp
// C++17 规则:
//   E1 = E2     先求右侧 E2
//   E1[E2]      先求下标 E2
//   E1 << E2    从左到右(<<、>>、&、|、^、逗号等)
std::map<int, int> m;
m[0] = m.size();        // 定义良好:先算 m.size()(此时是 0),再插入 m[0]
std::cout << f() << g(); // f() 一定先于 g() 执行
// 注意:函数实参之间的求值顺序仍然未指明

这消除了大量「未指定但经常碰巧正确」的隐患,不过实参顺序依旧不保证,旧习惯别全丢。

UTF-8 字符字面量 ​

C++17 新增 u8 字符字面量 u8'a'(此前 u8"..." 字符串 C++11 已有):

cpp
char c = u8'a';         // UTF-8 编码的字符字面量
// u8"你好"             // UTF-8 字符串字面量(C++11 起)
// C++20 起 u8 字面量的类型改为新的 char8_t,见 C++20 一篇

十六进制浮点字面量 ​

0x 前缀 + p 指数的浮点写法,与硬件浮点格式(二进制)精确对应:

cpp
double x = 0x1.8p3;     // 1.5 × 2^3 = 12.0
//      0x1.8:十六进制尾数(1 + 8/16 = 1.5)
//      p3:2 的 3 次方(p = power of 2,指数是十进制)

用途:浮点常量的位级精确表示(DSP、常量表),十进制字面量是做不到的。

折叠表达式 ​

变参模板的参数包不再需要递归展开,一行折叠:

cpp
template<typename... Args>
auto sum(Args... args) {
    return (args + ... + 0);            // 二元左折叠,空包安全返回 0
}

template<typename... Args>
void print_all(const Args&... args) {
    ((std::cout << args << ' '), ...);  // 一元左折叠:依次执行每个表达式
}

四种形式:

写法 名称 结合方式
(pack op ...) 一元右折叠 E1 + (E2 + E3)
(... op pack) 一元左折叠 (E1 + E2) + E3
(pack op ... op init) 二元右折叠 E1 + (E2 + init)
(init op ... op pack) 二元左折叠 (init + E1) + E2

一元折叠空包是错误(逗号、&&、|| 除外),所以求和用二元形式给初值 0。

泛型值模板参数 ​

非类型模板参数可以用 auto,让编译器推导其类型:

cpp
template<auto V>                    // V 的类型由实参推导
struct Constant {
    static constexpr auto value = V;
};

Constant<42> a;                     // V 是 int
Constant<'x'> b;                    // V 是 char
Constant<sizeof(int)> c;            // V 是 size_t 类型的值

// C++20 进一步允许任意字面类类型做模板参数

类模板参数的类型推导(CTAD) ​

类模板的模板参数可以从构造函数实参推导,不用手写模板参数列表:

cpp
std::vector v{1, 2, 3};                 // vector<int>(以前必须 vector<int>)
std::pair p{1, 2.5};                    // pair<int, double>
std::lock_guard lk(mtx);                // lock_guard<std::mutex>
std::atomic n{0};                       // atomic<int>

// 复杂推导靠"推导指引":
std::vector<int> src{1, 2, 3};
std::vector vec(src.begin(), src.end()); // 迭代器对 -> vector<int>,是指引的功劳

编译时 if(if constexpr) ​

if constexpr 在编译期剪掉不成立的分支,被剪分支甚至不要求能通过编译——替代大量 SFINAE:

cpp
template<typename T>
std::string stringify(const T& v) {
    if constexpr (std::is_pointer_v<T>)
        return stringify(*v);               // T 是 int 等非指针类型时,这行根本不实例化
    else if constexpr (std::is_same_v<T, bool>)
        return v ? "true" : "false";
    else
        return std::to_string(v);
}

与普通 if 的本质区别:普通 if 两个分支都要编译(只是运行期跳过),if constexpr 的死分支在编译期就消失,所以才能写「对指针才合法」的代码。

带有初始值设定项的选择语句 ​

if / switch 的条件位置可以带一条初始化语句,变量作用域限制在语句内:

cpp
// 以前 it 泄漏到外面作用域
if (auto it = m.find(key); it != m.end()) {
    use(it->second);
} else {
    it = m.emplace(key, 0).first;      // else 分支也能用 it
}
// it 在这里已不存在,名字不污染外层

switch (auto s = status(); s) {
    case Status::ok:     handle_ok();     break;
    case Status::retry:  handle_retry(s); break;
    default:             break;
}

constexpr 匿名函数 ​

lambda 隐式就是 constexpr(只要满足要求),也可以显式标注:

cpp
constexpr auto sq = [](int x) { return x * x; };   // 可在编译期调用
static_assert(sq(8) == 64, "");

constexpr int table[]{sq(1), sq(2), sq(3)};         // 1, 4, 9 编译期生成

配合放松的 constexpr 规则,编译期查表代码可以直接用 lambda 写。

inline 变量 ​

变量可以 inline,头文件里定义、跨翻译单元合并为一份实体:

cpp
// config.h
struct Config {
    static inline int log_level = 3;      // 以前必须在某个 .cpp 里再定义一次
};

inline constexpr double kGravity = 9.80665;   // 头文件常量不再需要每个 cpp 一份

解决的问题:C++11 的 static 成员变量或全局变量放头文件会导致「每个包含它的 .cpp 各有一份副本 / 重复定义」,只能声明与定义分离。inline 变量(含 inline constexpr)是头文件库的最后一块拼图。

结构化绑定 ​

把 tuple/pair/结构体/数组的成员一次性解包到具名变量:

cpp
std::map<std::string, int> ages{{"tom", 30}, {"jerry", 25}};
for (const auto& [name, age] : ages)          // 遍历 map 的最自然写法
    std::cout << name << ':' << age << '\n';

auto [ok, value] = try_parse("42");           // 多返回值
struct Point { int x; double y; };
auto [x, y] = Point{1, 2.5};                  // 结构体成员
int arr[3]{1, 2, 3};
auto [a, b, c] = arr;                         // 原生数组

原理:编译器生成一个隐藏的引用/拷贝对象,再把各个名字绑定到它的成员上——是绑定不是新变量,所以不能嵌套、个数必须对得上。

新的标准属性 ​

三个新的 [[...]] 属性,把「意图」告诉编译器以产生告警或优化:

cpp
[[nodiscard]] int compute();          // 调用方丢弃返回值 -> 告警(错误码防漏检)

void handle(int mode) {
    switch (mode) {
    case 1:
        prepare();
        [[fallthrough]];              // 显式贯穿到 case 2,压掉告警
    case 2:
        run();
        break;
    default:
        break;
    }
}

void debug_hook([[maybe_unused]] int verbose) {   // 某些构建里没用到也不告警
    // ...
}

用底层类型的值来初始化 enum 类型 ​

底层类型固定的枚举,可以直接从该类型的值构造(用于组合标志位):

cpp
enum class Flags : std::uint32_t { A = 1, B = 2, BOTH = 3 };

Flags f{0};               // 合法:花括号从底层类型初始化
Flags g{Flags::B | Flags::A};   // 注意 | 不接受(未定义枚举值的运算),仍是老问题
auto raw = static_cast<std::uint32_t>(f);

一些小的扩展 ​

cpp
namespace app::net::detail {        // 嵌套命名空间定义(以前只能一层层写)

}

#if __has_include(<optional>)       // 探测头文件是否存在
#  include <optional>
#endif

标准库特性 ​

文件系统 ​

<filesystem> 提供跨平台的路径、目录、文件操作:

cpp
namespace fs = std::filesystem;

fs::path p = "data/logs/app.txt";
fs::create_directories(p.parent_path());     // 递归建目录

for (const auto& entry : fs::directory_iterator("data")) {
    if (entry.is_regular_file())
        std::cout << entry.path().filename() << ' '
                  << fs::file_size(entry) << '\n';
}

auto copied = fs::copy_file("a.txt", "b.txt",
                            fs::copy_options::overwrite_existing);

关键点:fs::path 是可移植路径抽象(Windows 上 / 会规范化);遍历目录产生的异常用 std::filesystem::filesystem_error 捕获。

并行算法 ​

STL 算法接受执行策略参数,一行切换多线程/向量化:

cpp
#include <execution>

std::sort(std::execution::par, v.begin(), v.end());          // 多线程排序
std::for_each(std::execution::par, v.begin(), v.end(),
              [](double& x) { x = std::sqrt(x); });
std::count_if(std::execution::par_unseq, v.begin(), v.end(), // 还可向量化
              [](int x) { return x % 2 == 0; });

// 三种策略:seq(串行,默认)/ par(多线程)/ par_unseq(多线程+向量化)

注意:并行下元素访问函数不能引入数据竞争、不能抛异常(抛出即 std::terminate),算法内部怎么分块由实现决定。

数学特殊函数 ​

<cmath> 收录了一批科学计算特殊函数:beta、riemann_zeta、hermite、laguerre、assoc_legendre、椭圆积分等:

cpp
#include <cmath>
double b = std::beta(2.0, 3.0);        // 贝塔函数
double z = std::riemann_zeta(2.0);     // ζ(2) = π²/6
double h = std::hermite(3, 1.5);       // 埃尔米特多项式

string_view ​

指向字符串的非拥有只读视图:一对(指针,长度),零拷贝传参的标准答案:

cpp
void log_msg(std::string_view sv);          // 接受 string、字面量、子串,都不拷贝

std::string s = "hello world";

// 传统:s.substr(0, 5) 分配一个新 string
// 视图:
std::string_view whole = s;
std::string_view head  = whole.substr(0, 5);   // 只改指针和长度,零分配
log_msg(head);

// 两个坑:
// 1. 不保证 NUL 结尾,不能直接传给 C API
// 2. 引用的数据必须活得比视图久(返回局部 string 的 view 是悬垂)

std::byte 类型 ​

表示「裸字节」的独立类型(库特性,定义在 <cstddef>,P0298R3,常被误归入语言特性):只允许位运算、不允许算术,把「内存数据」和「数字」从类型上分开:

cpp
#include <cstddef>

std::byte b{0x0F};
b <<= 4;                                  // 位运算:可用
b |= std::byte{0x01};
auto n = std::to_integer<int>(b);         // 转数字必须显式

// b + 1;                                 // 错误:没有算术运算
// 它本质是 enum class byte : unsigned char,比 unsigned char 多了类型安全

用途:内存缓冲、序列化代码里区分「字节」与「整数」,避免 char 的符号问题与意外的算术。

any ​

std::any 存任意类型的值(类型安全的 void*),类型集合开放时使用:

cpp
std::any a = 42;
a = std::string("hello");            // 随时换成别的类型

if (auto* ps = std::any_cast<std::string>(&a))   // 指针形式:类型不符得 nullptr
    use(*ps);
try {
    std::any_cast<int>(a);           // 引用形式:类型不符抛 bad_any_cast
} catch (const std::bad_any_cast&) {}

variant ​

std::variant<A, B, ...> 是类型安全的 union:同一时刻存候选类型之一,候选集合固定:

cpp
std::variant<int, std::string> v = "hello";

// 按类型访问
if (auto* ps = std::get_if<std::string>(&v))
    use(*ps);

// 按当前内容分发(推荐)
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
std::visit(overloaded{
    [](int i)         { std::cout << "int " << i; },
    [](const std::string& s) { std::cout << "str " << s; },
}, v);

std::visit 对「当前是哪种类型」做编译期分发,overloaded 惯用法(配合 CTAD)让每个候选对应一个 lambda。

optional ​

std::optional<T> 表示「可能有值也可能没有」,替代魔法值和输出参数:

cpp
std::optional<int> parse_int(std::string_view sv) {
    if (sv.empty()) return std::nullopt;      // 显式的"没有"
    return std::stoi(std::string(sv));
}

auto n = parse_int("42");
if (n)                 // 有值
    use(*n);
int v = n.value_or(-1); // 没有就给默认

三个「词汇类型」按需求选:

invoke() ​

std::invoke 统一「一切可调用物」的调用语法(INVOKE 协议的落点):

cpp
struct Widget {
    int twice() const { return x * 2; }
    int x = 21;
};

Widget w;
std::invoke(&Widget::twice, w);            // 调成员函数 -> 42
std::invoke(&Widget::x, w);                // 访问成员 -> 21
std::invoke([](int x) { return x + 1; }, 1);  // 普通可调用 -> 2

它是 std::function、bind、ranges 算法内部的基础设施;自己写泛型回调框架时也用它,成员函数指针就不用特判了。

to_chars() 和 from_chars() ​

<charconv> 的底层字符串/数值转换:无 locale、无分配、无异常,最快的转换接口:

cpp
char buf[32];
auto [ptr, ec] = std::to_chars(buf, buf + sizeof buf, 3.14159);  // 写入,ec 表示成功与否
// buf 到 ptr 之间就是结果,没有 NUL 结尾

int value{};
auto [p2, ec2] = std::from_chars(buf, ptr, value);   // 解析回去

// 与 stoi/sscanf 对比:不分配、不抛异常、不读 locale、不跳过前导空白
// 浮点也支持,格式控制有限(fixed/scientific/general)

多态分配器 ​

<memory_resource>(pmr)把分配器变成容器类型的一部分,容器及其嵌套容器自动使用同一内存源:

cpp
#include <memory_resource>

std::byte stack_buf[1024];
std::pmr::monotonic_buffer_resource pool(stack_buf, sizeof stack_buf);
// 单调分配:只分配不回收,析构时整体归还——帧级/请求级内存的利器

{
    std::pmr::vector<int> v(&pool);       // 分配全部来自 pool
    for (int i = 0; i < 100; ++i)
        v.push_back(i);
}   // 释放是 O(1):pool 整体丢弃

原理:std::pmr::vector<T> 固定使用 std::pmr::polymorphic_allocator,通过虚接口 memory_resource::allocate() 分配,运行期可换内存源而容器类型不变——传统 allocator 换一个就得换容器类型。

scoped_lock ​

一个 RAII 锁对象管理多个互斥量,内部用 std::lock 思路避免死锁:

cpp
void transfer(Account& from, Account& to, long amount) {
    std::scoped_lock lk(from.mtx, to.mtx);   // 两把锁一次性安全获取
    from.withdraw(amount);
    to.deposit(amount);
}   // 析构按逆序解锁

scoped_lock 还统一了 lock_guard/unique_lock 的常规场景:普通单锁场景 std::scoped_lock lk(m) 与 lock_guard 等价,建议默认写它。

一些小的扩展 ​

cpp
std::shared_mutex rw;             // 不带定时的读写锁(比 shared_timed_mutex 更轻)

int c = std::clamp(42, 0, 10);    // 夹到区间 -> 10
auto g = std::gcd(24, 36);        // 最大公约数 -> 12
auto l = std::lcm(4, 6);          // 最小公倍数 -> 12

auto nf = std::not_fn(is_odd);    // 谓词取反:nf(3) == false
auto n1 = std::size(v);           // free 函数版 size/empty/data,泛型代码统一入口
auto& r = std::as_const(obj);     // 给对象加 const 引用(不拷贝)

小结 ​

C++17 没有单一「大杀器」,但它改变了日常代码的密度:结构化绑定与 CTAD 削掉大量样板,if constexpr 取代 SFINAE,词汇类型(optional/variant/string_view)让接口意图显式化。主流编译器对 C++17 的支持已经完备,是当下新项目的稳妥基线。

系列其他篇目:现代 C++ 新特性汇总 · C++11 新特性 · C++14 新特性 · C++20 新特性 · C++23 新特性

最近更新

基于 VitePress 构建